llms-py 2.0.9__py3-none-any.whl → 2.0.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. llms.py +14 -7
  2. llms_py-2.0.11.data/data/index.html +80 -0
  3. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/llms.json +5 -5
  4. llms_py-2.0.11.data/data/ui/Avatar.mjs +28 -0
  5. llms_py-2.0.11.data/data/ui/Brand.mjs +23 -0
  6. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/ChatPrompt.mjs +101 -69
  7. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/Main.mjs +43 -183
  8. llms_py-2.0.11.data/data/ui/ModelSelector.mjs +29 -0
  9. llms_py-2.0.11.data/data/ui/ProviderStatus.mjs +105 -0
  10. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/Recents.mjs +2 -1
  11. llms_py-2.0.11.data/data/ui/SettingsDialog.mjs +374 -0
  12. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/Sidebar.mjs +11 -27
  13. llms_py-2.0.11.data/data/ui/SignIn.mjs +64 -0
  14. llms_py-2.0.11.data/data/ui/SystemPromptEditor.mjs +31 -0
  15. llms_py-2.0.11.data/data/ui/SystemPromptSelector.mjs +36 -0
  16. llms_py-2.0.11.data/data/ui/Welcome.mjs +8 -0
  17. llms_py-2.0.11.data/data/ui/ai.mjs +80 -0
  18. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/app.css +76 -10
  19. llms_py-2.0.11.data/data/ui/lib/servicestack-vue.mjs +37 -0
  20. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/markdown.mjs +9 -2
  21. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/tailwind.input.css +13 -4
  22. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/threadStore.mjs +2 -2
  23. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/typography.css +109 -1
  24. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/utils.mjs +8 -2
  25. {llms_py-2.0.9.dist-info → llms_py-2.0.11.dist-info}/METADATA +33 -25
  26. llms_py-2.0.11.dist-info/RECORD +40 -0
  27. llms_py-2.0.9.data/data/index.html +0 -64
  28. llms_py-2.0.9.data/data/ui/lib/servicestack-vue.min.mjs +0 -37
  29. llms_py-2.0.9.dist-info/RECORD +0 -30
  30. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/requirements.txt +0 -0
  31. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/App.mjs +0 -0
  32. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/fav.svg +0 -0
  33. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/lib/highlight.min.mjs +0 -0
  34. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/lib/idb.min.mjs +0 -0
  35. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/lib/marked.min.mjs +0 -0
  36. /llms_py-2.0.9.data/data/ui/lib/servicestack-client.min.mjs → /llms_py-2.0.11.data/data/ui/lib/servicestack-client.mjs +0 -0
  37. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/lib/vue-router.min.mjs +0 -0
  38. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui/lib/vue.min.mjs +0 -0
  39. {llms_py-2.0.9.data → llms_py-2.0.11.data}/data/ui.json +0 -0
  40. {llms_py-2.0.9.dist-info → llms_py-2.0.11.dist-info}/WHEEL +0 -0
  41. {llms_py-2.0.9.dist-info → llms_py-2.0.11.dist-info}/entry_points.txt +0 -0
  42. {llms_py-2.0.9.dist-info → llms_py-2.0.11.dist-info}/licenses/LICENSE +0 -0
  43. {llms_py-2.0.9.dist-info → llms_py-2.0.11.dist-info}/top_level.txt +0 -0
llms.py CHANGED
@@ -22,7 +22,7 @@ from aiohttp import web
22
22
  from pathlib import Path
23
23
  from importlib import resources # Py≥3.9 (pip install importlib_resources for 3.7/3.8)
24
24
 
25
- VERSION = "2.0.9"
25
+ VERSION = "2.0.11"
26
26
  _ROOT = None
27
27
  g_config_path = None
28
28
  g_ui_path = None
@@ -64,8 +64,8 @@ def chat_summary(chat):
64
64
  elif 'file' in item:
65
65
  if 'file_data' in item['file']:
66
66
  data = item['file']['file_data']
67
- prefix = url.split(',', 1)[0]
68
- item['file']['file_data'] = prefix + f",({len(url) - len(prefix)})"
67
+ prefix = data.split(',', 1)[0]
68
+ item['file']['file_data'] = prefix + f",({len(data) - len(prefix)})"
69
69
  return json.dumps(clone, indent=2)
70
70
 
71
71
  def gemini_chat_summary(gemini_chat):
@@ -444,7 +444,14 @@ class GoogleProvider(OpenAiProvider):
444
444
  async with aiohttp.ClientSession() as session:
445
445
  for message in chat['messages']:
446
446
  if message['role'] == 'system':
447
- system_prompt = message
447
+ content = message['content']
448
+ if isinstance(content, list):
449
+ for item in content:
450
+ if 'text' in item:
451
+ system_prompt = item['text']
452
+ break
453
+ elif isinstance(content, str):
454
+ system_prompt = content
448
455
  elif 'content' in message:
449
456
  if isinstance(message['content'], list):
450
457
  parts = []
@@ -520,7 +527,7 @@ class GoogleProvider(OpenAiProvider):
520
527
  # Add system instruction if present
521
528
  if system_prompt is not None:
522
529
  gemini_chat['systemInstruction'] = {
523
- "parts": [{"text": system_prompt['content']}]
530
+ "parts": [{"text": system_prompt}]
524
531
  }
525
532
 
526
533
  if 'stop' in chat:
@@ -1257,7 +1264,7 @@ def main():
1257
1264
  app.router.add_route('*', '/{tail:.*}', index_handler)
1258
1265
 
1259
1266
  if os.path.exists(g_ui_path):
1260
- async def ui_json_handler(request):
1267
+ async def ui_config_handler(request):
1261
1268
  with open(g_ui_path, "r") as f:
1262
1269
  ui = json.load(f)
1263
1270
  if 'defaults' not in ui:
@@ -1269,7 +1276,7 @@ def main():
1269
1276
  "disabled": disabled
1270
1277
  }
1271
1278
  return web.json_response(ui)
1272
- app.router.add_get('/ui.json', ui_json_handler)
1279
+ app.router.add_get('/config', ui_config_handler)
1273
1280
 
1274
1281
  print(f"Starting server on port {port}...")
1275
1282
  web.run_app(app, host='0.0.0.0', port=port)
@@ -0,0 +1,80 @@
1
+ <html>
2
+ <head>
3
+ <title>llms.py</title>
4
+ <link rel="stylesheet" href="/ui/typography.css">
5
+ <link rel="stylesheet" href="/ui/app.css">
6
+ <link rel="icon" type="image/svg" href="/ui/fav.svg">
7
+ <style>
8
+ [type='button'],button[type='submit']{cursor:pointer}
9
+ [type='checkbox'].switch:checked:hover,
10
+ [type='checkbox'].switch:checked:focus,
11
+ [type='checkbox'].switch:checked,
12
+ [type='checkbox'].switch:focus,
13
+ [type='checkbox'].switch
14
+ {
15
+ border: none;
16
+ background: none;
17
+ outline: none;
18
+ box-shadow: none;
19
+ cursor: pointer;
20
+ }
21
+ </style>
22
+ </head>
23
+ <script type="importmap">
24
+ {
25
+ "imports": {
26
+ "vue": "/ui/lib/vue.min.mjs",
27
+ "vue-router": "/ui/lib/vue-router.min.mjs",
28
+ "@servicestack/client": "/ui/lib/servicestack-client.mjs",
29
+ "@servicestack/vue": "/ui/lib/servicestack-vue.mjs",
30
+ "idb": "/ui/lib/idb.min.mjs",
31
+ "marked": "/ui/lib/marked.min.mjs",
32
+ "highlight.js": "/ui/lib/highlight.min.mjs"
33
+ }
34
+ }
35
+ </script>
36
+ <body>
37
+ <div id="app"></div>
38
+ </body>
39
+ <script type="module">
40
+ import { createApp, defineAsyncComponent } from 'vue'
41
+ import { createWebHistory, createRouter } from "vue-router"
42
+ import ServiceStackVue from "@servicestack/vue"
43
+ import App from '/ui/App.mjs'
44
+ import ai from '/ui/ai.mjs'
45
+ import SettingsDialog from '/ui/SettingsDialog.mjs'
46
+
47
+ const { config, models } = await ai.init()
48
+ const MainComponent = defineAsyncComponent(() => import(ai.base + '/ui/Main.mjs'))
49
+ const RecentsComponent = defineAsyncComponent(() => import(ai.base + '/ui/Recents.mjs'))
50
+
51
+ const Components = {
52
+ SettingsDialog,
53
+ }
54
+
55
+ const routes = [
56
+ { path: '/', component: MainComponent },
57
+ { path: '/c/:id', component: MainComponent },
58
+ { path: '/recents', component: RecentsComponent },
59
+ { path: '/:fallback(.*)*', component: MainComponent }
60
+ ]
61
+ routes.forEach(r => r.path = ai.base + r.path)
62
+ const router = createRouter({
63
+ history: createWebHistory(),
64
+ routes,
65
+ })
66
+ const app = createApp(App, { config, models })
67
+ app.use(router)
68
+ app.use(ServiceStackVue)
69
+ app.provide('ai', ai)
70
+ app.provide('config', config)
71
+ app.provide('models', models)
72
+ Object.keys(Components).forEach(name => {
73
+ app.component(name, Components[name])
74
+ })
75
+
76
+ window.ai = app.config.globalProperties.$ai = ai
77
+
78
+ app.mount('#app')
79
+ </script>
80
+ </html>
@@ -86,7 +86,7 @@
86
86
  "enabled": true,
87
87
  "type": "OpenAiProvider",
88
88
  "base_url": "https://openrouter.ai/api",
89
- "api_key": "$OPENROUTER_FREE_API_KEY",
89
+ "api_key": "$OPENROUTER_API_KEY",
90
90
  "models": {
91
91
  "qwen2.5vl": "qwen/qwen2.5-vl-32b-instruct:free",
92
92
  "llama4:109b": "meta-llama/llama-4-scout:free",
@@ -95,10 +95,8 @@
95
95
  "deepseek-r1:671b": "deepseek/deepseek-r1-0528:free",
96
96
  "gemini-2.0-flash": "google/gemini-2.0-flash-exp:free",
97
97
  "glm-4.5-air": "z-ai/glm-4.5-air:free",
98
- "grok-4-fast": "x-ai/grok-4-fast:free",
99
98
  "mai-ds-r1": "microsoft/mai-ds-r1:free",
100
99
  "llama3.3:70b": "meta-llama/llama-3.3-70b-instruct:free",
101
- "kimi-k2": "moonshotai/kimi-k2:free",
102
100
  "nemotron-nano:9b": "nvidia/nemotron-nano-9b-v2:free",
103
101
  "deepseek-r1-distill-llama:70b": "deepseek/deepseek-r1-distill-llama-70b:free",
104
102
  "gpt-oss:20b": "openai/gpt-oss-20b:free",
@@ -107,7 +105,6 @@
107
105
  "devstral-small": "mistralai/devstral-small-2505:free",
108
106
  "venice-uncensored:24b": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
109
107
  "llama3.3:8b": "meta-llama/llama-3.3-8b-instruct:free",
110
- "llama3.1:405b": "meta-llama/llama-3.1-405b-instruct:free",
111
108
  "kimi-dev:72b": "moonshotai/kimi-dev-72b:free",
112
109
  "gemma3:27b": "google/gemma-3-27b-it:free",
113
110
  "qwen3-coder": "qwen/qwen3-coder:free",
@@ -176,7 +173,7 @@
176
173
  }
177
174
  },
178
175
  "ollama": {
179
- "enabled": false,
176
+ "enabled": true,
180
177
  "type": "OllamaProvider",
181
178
  "base_url": "http://localhost:11434",
182
179
  "models": {},
@@ -239,6 +236,7 @@
239
236
  "qwen3-max": "qwen/qwen3-max",
240
237
  "qwen3-vl:235b": "qwen/qwen3-vl-235b-a22b-instruct",
241
238
  "qwen3-vl-thinking:235b": "qwen/qwen3-vl-235b-a22b-thinking",
239
+ "ling-1t": "inclusionai/ling-1t",
242
240
  "llama4:109b": "meta-llama/llama-4-scout",
243
241
  "llama4:400b": "meta-llama/llama-4-maverick"
244
242
  }
@@ -286,6 +284,7 @@
286
284
  "base_url": "https://api.openai.com",
287
285
  "api_key": "$OPENAI_API_KEY",
288
286
  "models": {
287
+ "gpt-5-pro": "openai/gpt-5-pro",
289
288
  "gpt-5-codex": "gpt-5-codex",
290
289
  "gpt-audio": "gpt-audio",
291
290
  "gpt-realtime": "gpt-realtime",
@@ -389,6 +388,7 @@
389
388
  "qwen3-coder:30b": "qwen3-coder-30b-a3b-instruct",
390
389
  "qwen3-vl-thinking:235b": "qwen3-vl-235b-a22b-thinking",
391
390
  "qwen3-vl:235b": "qwen3-vl-235b-a22b-instruct",
391
+ "qwen3-vl:30b": "qwen3-vl-30b-a3b-instruct",
392
392
  "qwen2.5-vl:72b": "qwen2.5-vl-72b-instruct",
393
393
  "qwen2.5-vl:32b": "qwen2.5-vl-32b-instruct",
394
394
  "qwen2.5-vl:7b": "qwen2.5-vl-7b-instruct",
@@ -0,0 +1,28 @@
1
+ import { computed, inject } from "vue"
2
+
3
+ export default {
4
+ template:`
5
+ <div v-if="$ai.auth?.profileUrl" :title="authTitle">
6
+ <img :src="$ai.auth.profileUrl" class="size-8 rounded-full" />
7
+ </div>
8
+ `,
9
+ setup() {
10
+ const ai = inject('ai')
11
+ const authTitle = computed(() => {
12
+ if (!ai.auth) return ''
13
+ const { userId, userName, displayName, bearerToken, roles } = ai.auth
14
+ const name = userName || displayName
15
+ const prefix = roles && roles.includes('Admin') ? 'Admin' : 'Name'
16
+ const sb = [
17
+ name ? `${prefix}: ${name}` : '',
18
+ `API Key: ${bearerToken}`,
19
+ `${userId}`,
20
+ ]
21
+ return sb.filter(x => x).join('\n')
22
+ })
23
+
24
+ return {
25
+ authTitle,
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,23 @@
1
+ export default {
2
+ template:`
3
+ <div class="flex-shrink-0 px-4 py-4 border-b border-gray-200 bg-white min-h-16 select-none">
4
+ <div class="flex items-center justify-between">
5
+ <button type="button"
6
+ @click="$emit('home')"
7
+ class="text-lg font-semibold text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
8
+ title="Go back to initial state"
9
+ >
10
+ History
11
+ </button>
12
+ <button type="button"
13
+ @click="$emit('new')"
14
+ class="text-gray-900 hover:text-blue-600 focus:outline-none transition-colors"
15
+ title="New Chat"
16
+ >
17
+ <svg class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></g></svg>
18
+ </button>
19
+ </div>
20
+ </div>
21
+ `,
22
+ emits:['home','new'],
23
+ }
@@ -11,7 +11,6 @@ export function useChatPrompt() {
11
11
  const attachedFiles = ref([])
12
12
  const isGenerating = ref(false)
13
13
  const errorStatus = ref(null)
14
- const errorMessage = ref(null)
15
14
  const hasImage = () => attachedFiles.value.some(f => imageExts.includes(lastRightPart(f.name, '.')))
16
15
  const hasAudio = () => attachedFiles.value.some(f => audioExts.includes(lastRightPart(f.name, '.')))
17
16
  const hasFile = () => attachedFiles.value.length > 0
@@ -28,7 +27,6 @@ export function useChatPrompt() {
28
27
  messageText,
29
28
  attachedFiles,
30
29
  errorStatus,
31
- errorMessage,
32
30
  isGenerating,
33
31
  get generating() {
34
32
  return isGenerating.value
@@ -44,23 +42,32 @@ export function useChatPrompt() {
44
42
  export default {
45
43
  template:`
46
44
  <div class="mx-auto max-w-3xl">
47
- <div class="flex space-x-3">
48
- <!-- Attach (+) button -->
49
- <div>
50
- <button type="button"
51
- @click="triggerFilePicker"
45
+ <SettingsDialog :isOpen="showSettings" @close="showSettings = false" />
46
+ <div class="flex space-x-2">
47
+ <!-- Attach (+) button and Settings button -->
48
+ <div class="mt-1.5 flex flex-col space-y-1 items-center">
49
+ <div>
50
+ <button type="button"
51
+ @click="triggerFilePicker"
52
+ :disabled="isGenerating || !model"
53
+ class="size-8 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
54
+ title="Attach image or audio">
55
+ <svg class="size-5" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256">
56
+ <path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"></path>
57
+ </svg>
58
+ </button>
59
+ <!-- Hidden file input -->
60
+ <input ref="fileInput" type="file" multiple @change="onFilesSelected"
61
+ class="hidden" accept="image/*,audio/*,.pdf,.doc,.docx,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
62
+ />
63
+ </div>
64
+ <div>
65
+ <button type="button" title="Settings" @click="showSettings = true"
52
66
  :disabled="isGenerating || !model"
53
- class="mt-2 h-10 w-10 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed"
54
- title="Attach image or audio">
55
- <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
56
- <line x1="12" y1="5" x2="12" y2="19"></line>
57
- <line x1="5" y1="12" x2="19" y2="12"></line>
58
- </svg>
59
- </button>
60
- <!-- Hidden file input -->
61
- <input ref="fileInput" type="file" multiple @change="onFilesSelected"
62
- class="hidden" accept="image/*,audio/*,.pdf,.doc,.docx,.xml,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
63
- />
67
+ class="size-8 flex items-center justify-center rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed">
68
+ <svg class="size-4 text-gray-600 disabled:text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256"><path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H199a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h97a32,32,0,0,0,62,0h17a8,8,0,0,0,0-16Zm-48,24a16,16,0,1,1,16-16A16,16,0,0,1,168,192Z"></path></svg>
69
+ </button>
70
+ </div>
64
71
  </div>
65
72
 
66
73
  <div class="flex-1">
@@ -70,7 +77,7 @@ export default {
70
77
  @keydown.enter.exact.prevent="sendMessage"
71
78
  @keydown.enter.shift.exact="addNewLine"
72
79
  placeholder="Type your message... (Enter to send, Shift+Enter for new line)"
73
- rows="2"
80
+ rows="3"
74
81
  class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
75
82
  :disabled="isGenerating || !model"
76
83
  ></textarea>
@@ -90,16 +97,16 @@ export default {
90
97
  </div>
91
98
  </div>
92
99
 
93
- <div>
100
+ <div class="pt-3">
94
101
  <button title="Send (Enter)" type="button"
95
102
  @click="sendMessage"
96
103
  :disabled="!messageText.trim() || isGenerating || !model"
97
- class="mt-2 p-2 flex items-center justify-center rounded-full bg-gray-700 text-white transition-colors hover:opacity-70 focus-visible:outline-none focus-visible:outline-black disabled:bg-[#D7D7D7] disabled:text-[#f4f4f4] disabled:hover:opacity-100 dark:bg-white dark:text-black dark:focus-visible:outline-white disabled:dark:bg-token-text-quaternary dark:disabled:text-token-main-surface-secondary">
98
- <svg v-if="isGenerating" class="size-6 animate-spin" fill="none" viewBox="0 0 24 24">
104
+ class="p-2 flex items-center justify-center rounded-full bg-gray-700 text-white transition-colors hover:opacity-70 focus-visible:outline-none focus-visible:outline-black disabled:bg-[#D7D7D7] disabled:text-[#f4f4f4] disabled:hover:opacity-100 dark:bg-white dark:text-black dark:focus-visible:outline-white disabled:dark:bg-token-text-quaternary dark:disabled:text-token-main-surface-secondary">
105
+ <svg v-if="isGenerating" class="size-8 animate-spin" fill="none" viewBox="0 0 24 24">
99
106
  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
100
107
  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
101
108
  </svg>
102
- <svg v-else class="size-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m3.165 19.503l7.362-16.51c.59-1.324 2.355-1.324 2.946 0l7.362 16.51c.667 1.495-.814 3.047-2.202 2.306l-5.904-3.152c-.459-.245-1-.245-1.458 0l-5.904 3.152c-1.388.74-2.87-.81-2.202-2.306"></path></svg>
109
+ <svg v-else class="size-8" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="20" stroke-dashoffset="20" d="M12 21l0 -17.5"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.2s" values="20;0"/></path><path stroke-dasharray="12" stroke-dashoffset="12" d="M12 3l7 7M12 3l-7 7"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.2s" dur="0.2s" values="12;0"/></path></g></svg>
103
110
  </button>
104
111
  </div>
105
112
  </div>
@@ -116,18 +123,19 @@ export default {
116
123
  }
117
124
  },
118
125
  setup(props) {
126
+ const ai = inject('ai')
127
+ const chatSettings = inject('chatSettings')
119
128
  const router = useRouter()
120
129
  const config = inject('config')
121
130
  const chatPrompt = inject('chatPrompt')
122
- const {
123
- messageText,
124
- attachedFiles,
125
- isGenerating,
131
+ const {
132
+ messageText,
133
+ attachedFiles,
134
+ isGenerating,
126
135
  errorStatus,
127
- errorMessage,
128
- hasImage,
129
- hasAudio,
130
- hasFile
136
+ hasImage,
137
+ hasAudio,
138
+ hasFile
131
139
  } = chatPrompt
132
140
  const threads = inject('threads')
133
141
  const {
@@ -135,6 +143,8 @@ export default {
135
143
  } = threads
136
144
 
137
145
  const fileInput = ref(null)
146
+ const showSettings = ref(false)
147
+ const { applySettings } = chatSettings
138
148
 
139
149
  // File attachments (+) handlers
140
150
  const triggerFilePicker = () => {
@@ -185,7 +195,7 @@ export default {
185
195
  if (!messageText.value.trim() || isGenerating.value || !props.model) return
186
196
 
187
197
  // Clear any existing error message
188
- errorStatus.value = errorMessage.value = null
198
+ errorStatus.value = null
189
199
 
190
200
  let message = messageText.value.trim()
191
201
  if (attachedFiles.value.length) {
@@ -207,7 +217,7 @@ export default {
207
217
  const newThread = await threads.createThread('New Chat', props.model, props.systemPrompt)
208
218
  threadId = newThread.id
209
219
  // Navigate to the new thread URL
210
- router.push(`/c/${newThread.id}`)
220
+ router.push(`${ai.base}/c/${newThread.id}`)
211
221
  } else {
212
222
  threadId = currentThread.value.id
213
223
  // Update the existing thread's model and systemPrompt to match current selection
@@ -233,14 +243,19 @@ export default {
233
243
  if (props.systemPrompt?.trim()) {
234
244
  messages.unshift({
235
245
  role: 'system',
236
- content: props.systemPrompt.trim()
246
+ content: [
247
+ { type: 'text', text: props.systemPrompt }
248
+ ]
237
249
  })
238
250
  }
239
251
 
240
252
  const chatRequest = createChatRequest()
241
253
  chatRequest.model = props.model
242
254
 
243
- console.log('chatRequest', chatRequest, hasImage(), hasAudio(), attachedFiles.value.length, attachedFiles.value)
255
+ // Apply user settings
256
+ applySettings(chatRequest)
257
+
258
+ console.debug('chatRequest', chatRequest, hasImage(), hasAudio(), attachedFiles.value.length, attachedFiles.value)
244
259
 
245
260
  function setContentText(chatRequest, text) {
246
261
  // Replace text message
@@ -256,7 +271,7 @@ export default {
256
271
  if (hasImage()) {
257
272
  const imageMessage = chatRequest.messages.find(m =>
258
273
  m.role === 'user' && Array.isArray(m.content) && m.content.some(c => c.type === 'image_url'))
259
- console.log('hasImage', chatRequest, imageMessage)
274
+ console.debug('hasImage', chatRequest, imageMessage)
260
275
  if (imageMessage) {
261
276
  const imgs = []
262
277
  let imagePart = deepClone(imageMessage.content.find(c => c.type === 'image_url'))
@@ -272,7 +287,7 @@ export default {
272
287
  }
273
288
 
274
289
  } else if (hasAudio()) {
275
- console.log('hasAudio', chatRequest)
290
+ console.debug('hasAudio', chatRequest)
276
291
  const audioMessage = chatRequest.messages.find(m =>
277
292
  m.role === 'user' && Array.isArray(m.content) && m.content.some(c => c.type === 'input_audio'))
278
293
  if (audioMessage) {
@@ -289,7 +304,7 @@ export default {
289
304
  setContentText(chatRequest, message)
290
305
  }
291
306
  } else if (attachedFiles.value.length) {
292
- console.log('hasFile', chatRequest)
307
+ console.debug('hasFile', chatRequest)
293
308
  const fileMessage = chatRequest.messages.find(m =>
294
309
  m.role === 'user' && Array.isArray(m.content) && m.content.some(c => c.type === 'file'))
295
310
  if (fileMessage) {
@@ -306,62 +321,80 @@ export default {
306
321
  }
307
322
 
308
323
  } else {
309
- console.log('hasText', chatRequest)
324
+ console.debug('hasText', chatRequest)
310
325
  // Chat template message needs to be empty
311
326
  chatRequest.messages = []
312
327
  messages.forEach(m => chatRequest.messages.push({
313
328
  role: m.role,
314
- content: m.content
329
+ content: typeof m.content === 'string'
330
+ ? [{ type: 'text', text: m.content }]
331
+ : m.content
315
332
  }))
316
333
  }
317
334
 
318
335
  // Send to API
319
- const response = await fetch('/v1/chat/completions', {
320
- method: 'POST',
321
- headers: {
322
- 'Content-Type': 'application/json'
323
- },
336
+ console.debug('chatRequest', chatRequest)
337
+ const response = await ai.post('/v1/chat/completions', {
324
338
  body: JSON.stringify(chatRequest)
325
339
  })
326
340
 
341
+ let result = null
327
342
  if (!response.ok) {
328
- errorStatus.value = `HTTP ${response.status} ${response.statusText}`
329
- let errorBody = ''
343
+ errorStatus.value = {
344
+ errorCode: `HTTP ${response.status} ${response.statusText}`,
345
+ message: null,
346
+ stackTrace: null
347
+ }
348
+ let errorBody = null
330
349
  try {
331
350
  errorBody = await response.text()
332
351
  if (errorBody) {
333
352
  // Try to parse as JSON for better formatting
334
353
  try {
335
354
  const errorJson = JSON.parse(errorBody)
336
- errorBody = JSON.stringify(errorJson, null, 2)
355
+ const status = errorJson?.responseStatus
356
+ if (status) {
357
+ errorStatus.value.errorCode += ` ${status.errorCode}`
358
+ errorStatus.value.message = status.message
359
+ errorStatus.value.stackTrace = status.stackTrace
360
+ } else {
361
+ errorStatus.value.stackTrace = JSON.stringify(errorJson, null, 2)
362
+ }
337
363
  } catch (e) {
338
364
  }
339
365
  }
340
366
  } catch (e) {
341
367
  // If we can't read the response body, just use the status
342
368
  }
343
- throw new Error(errorBody || '')
369
+ } else {
370
+ try {
371
+ result = await response.json()
372
+ } catch (e) {
373
+ errorStatus.value = {
374
+ errorCode: 'Error',
375
+ message: e.message,
376
+ stackTrace: null
377
+ }
378
+ }
344
379
  }
345
380
 
346
- const result = await response.json()
347
-
348
- if (result.error) {
349
- throw new Error(result.error)
381
+ if (result?.error) {
382
+ errorStatus.value ??= {
383
+ errorCode: 'Error',
384
+ }
385
+ errorStatus.value.message = result.error
386
+ }
387
+
388
+ if (!errorStatus.value) {
389
+ // Add assistant response (save entire message including reasoning)
390
+ const assistantMessage = result.choices?.[0]?.message
391
+ await threads.addMessageToThread(threadId, assistantMessage)
392
+
393
+ nextTick(addCopyButtons)
394
+
395
+ attachedFiles.value = []
396
+ // Error will be cleared when user sends next message (no auto-timeout)
350
397
  }
351
-
352
- // Add assistant response (save entire message including reasoning)
353
- const assistantMessage = result.choices?.[0]?.message
354
- await threads.addMessageToThread(threadId, assistantMessage)
355
-
356
- nextTick(addCopyButtons)
357
-
358
- attachedFiles.value = []
359
-
360
- } catch (error) {
361
- console.error('Error sending message:', error)
362
- errorMessage.value = error.message
363
-
364
- // Error will be cleared when user sends next message (no auto-timeout)
365
398
  } finally {
366
399
  isGenerating.value = false
367
400
  }
@@ -375,10 +408,9 @@ export default {
375
408
  return {
376
409
  isGenerating,
377
410
  attachedFiles,
378
- errorStatus,
379
- errorMessage,
380
411
  messageText,
381
412
  fileInput,
413
+ showSettings,
382
414
  triggerFilePicker,
383
415
  onFilesSelected,
384
416
  removeAttachment,